今天我們把首頁做成一個單頁 Demo,選圖、三顆按鈕(灰階/模糊/銳化)、即時顯示尺寸與處理時間,並且沿用前幾天做好的零拷貝資料流與錯誤訊息協議。可以把下面整包貼進新資料夾,輕輕鬆鬆。
清晰的解釋以下流程:
.wasm
;2. 怎麼把像素丟進去、把結果拿回來;3. 這一來一回要花多少時間。package.json
(固定 ESM,鎖住入口與 dev script)
{
"name": "rustwasm-test",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"img-wasm": "^0.1.0"
},
"devDependencies": {
"vite": "^5.4.0",
"typescript": "^5.4.0"
}
}
接著放兩個檔:index.html
與 main.ts
。注意:wasm-pack
預設會輸出 rusttest_wasm_bg.wasm
;如果發佈時改過 --out-name
,下方 ?url
路徑要一起改。
index.html
選圖/三顆按鈕/資訊列
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>img-wasm demo</title>
<style>
body { font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; }
#toolbar { display:flex; gap:12px; align-items:center; flex-wrap: wrap; }
#stats { margin-top:8px; color:#555; }
canvas { display:block; margin-top:12px; max-width:100%; border-radius:8px; }
button:disabled { opacity:.5; cursor:not-allowed; }
.pill { padding:2px 8px; border-radius:999px; background:#f3f3f3; margin-right:8px; }
#error { margin-top:8px; color:#b00020; white-space:pre-wrap; }
</style>
</head>
<body>
<div id="toolbar">
<input id="pick" type="file" accept="image/*" />
<button id="btn-gray" disabled>灰階</button>
<button id="btn-blur" disabled>模糊 r=2</button>
<button id="btn-sharp" disabled>銳化</button>
<span class="pill" id="dim">–</span>
<span class="pill" id="timing">–</span>
<span class="pill" id="wasm">WASM:載入中…</span>
</div>
<div id="stats"></div>
<div id="error"></div>
<canvas id="cv"></canvas>
<script type="module" src="/main.ts"></script>
</body>
</html>
main.ts
這份程式做了四件事:
.wasm
檔;{code,message,hint}
呈現。import init, {
memory,
ensure_buffer,
buffer_ptr,
buffer_len,
load_pixels,
run_pipeline_inplace,
} from 'img-wasm'
import wasmUrl from 'rustwasm-test/rustwasm-test_bg.wasm?url'
await init({ module_or_path: wasmUrl })
const cv = document.querySelector<HTMLCanvasElement>('#cv')!
const ctx = cv.getContext('2d', { willReadFrequently: true })!
const pick = document.querySelector<HTMLInputElement>('#pick')!
const btnGray = document.querySelector<HTMLButtonElement>('#btn-gray')!
const btnBlur = document.querySelector<HTMLButtonElement>('#btn-blur')!
const btnSharp = document.querySelector<HTMLButtonElement>('#btn-sharp')!
const pillDim = document.querySelector<HTMLSpanElement>('#dim')!
const pillTiming = document.querySelector<HTMLSpanElement>('#timing')!
const pillWasm = document.querySelector<HTMLSpanElement>('#wasm')!
const errorEl = document.querySelector<HTMLDivElement>('#error')!
pillWasm.textContent = 'WASM:ready'
let W = 0, H = 0
function toJsError(e: any) {
const code = e?.code ?? e?.error?.code ?? 'E_UNKNOWN'
const message = e?.message ?? e?.error?.message ?? String(e)
const hint = e?.hint ?? e?.error?.hint
const err = new Error(message) as Error & { code: string; hint?: string }
err.code = code
if (hint) (err as any).hint = hint
return err
}
function setButtons(enabled: boolean) {
btnGray.disabled = btnBlur.disabled = btnSharp.disabled = !enabled
}
function showDims() {
pillDim.textContent = W ? `${W}×${H}` : '–'
}
function measure<T>(fn: () => T) {
const t0 = performance.now()
const ret = fn()
const ms = performance.now() - t0
return { ret, ms }
}
// 載圖 → 畫到 canvas
pick.onchange = () => {
const file = pick.files?.[0]; if (!file) return
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
W = img.naturalWidth; H = img.naturalHeight
cv.width = W; cv.height = H
ctx.drawImage(img, 0, 0)
showDims()
setButtons(true)
URL.revokeObjectURL(url)
errorEl.textContent = ''
pillTiming.textContent = '–'
}
img.src = url
}
// 把像素一次放進 WASM,算完再一次貼回
async function runInplace(ops: unknown[]) {
if (!W || !H) return
errorEl.textContent = ''
try {
const imgData = ctx.getImageData(0, 0, W, H)
// JS→WASM
const bytes = new Uint8Array(imgData.data.buffer) // 與 ClampedArray 共用底層
ensure_buffer(bytes.length)
load_pixels(bytes)
// 在 WASM 內跑完整管線
const { ms } = measure(() => {
run_pipeline_inplace(W, H, ops)
})
// WASM → JS
const ptr = buffer_ptr()
const len = buffer_len()
const view = new Uint8Array((memory as WebAssembly.Memory).buffer, ptr, len)
imgData.data.set(view)
ctx.putImageData(imgData, 0, 0)
pillTiming.textContent = `${ms.toFixed(2)} ms`
} catch (e) {
const err = toJsError(e)
errorEl.textContent = `[${(err as any).code}] ${err.message}${(err as any).hint ? '\n' + (err as any).hint : ''}`
}
}
// 灰階/模糊/銳化
btnGray.onclick = () => runInplace([{ kind: 'grayscale' }])
btnBlur.onclick = () => runInplace([{ kind: 'blur', r: 2 }])
btnSharp.onclick = () => runInplace([{ kind: 'conv3x3', k: [0,-1,0, -1,5,-1, 0,-1,0] }])
啟動
pnpm dev
為什麼這樣長相?
wasmUrl
用?url
讓 bundler 在建置時把.wasm
當靜態資源處理,瀏覽器可直接抓。ensure_buffer → load_pixels → run_pipeline_inplace → buffer_ptr/len
這條鏈,確保整段只發生兩次拷貝(一次進、一次出),符合 Day 16 的零拷貝資料流目標。- 每次呼叫
ensure_buffer
之後 再用buffer_ptr/len
取 view,避免memory.grow
造成舊視圖失效(Day 19 已經把這條規則寫進錯誤碼的 hint)。